Skip to content

feat: add agent-managed forked side threads#4246

Closed
nassimna wants to merge 2 commits into
pingdotgg:mainfrom
nassimna:agent/forked-side-threads
Closed

feat: add agent-managed forked side threads#4246
nassimna wants to merge 2 commits into
pingdotgg:mainfrom
nassimna:agent/forked-side-threads

Conversation

@nassimna

@nassimna nassimna commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

What Changed

  • Added a first-class thread.fork orchestration command that copies conversation history through a completed turn and persists the fork lineage.
  • Added project-scoped thread_list, thread_create, thread_fork, thread_send, and thread_archive MCP tools so an agent can coordinate sibling work without leaving T3 Code.
  • Added provider-session fork plumbing and Codex thread/fork support so a side thread retains native provider context.
  • Added a side-chat surface in the right panel and kept forked threads addressable in the project thread list.
  • Excluded the currently running agent turn when thread_fork chooses its default fork point. This lets an agent call the tool during its own response while inheriting the last completed turn.

Why

Agents can currently work only in the active conversation. Delegating an investigation or exploring an alternative requires losing context, manually creating another thread, or interrupting the main task.

This adds one project-native coordination path: an agent can fork the current conversation, open the result beside the main chat, and continue both threads independently while T3 Code retains their lineage.

Scope

This is one end-to-end feature, but it crosses the orchestration schema, projection storage, provider lifecycle, MCP surface, client commands, and side-panel UI. The layers are submitted together because a partial implementation would either lose lineage/context or expose a non-functional tool.

Verification

  • vp test run on the six affected MCP, orchestration, migration, Codex runtime, and right-panel test files: 56 tests passed.
  • Contracts, server, and web package typechecks passed.
  • Integrated browser verification confirmed that an agent can call thread_fork during its own running turn, open the side chat, and inherit the completed source history.

UI Evidence

Before

Single conversation without an open side chat

After

Agent-created fork opened as a side chat

Interaction

Short agent-managed fork recording

Checklist

  • The change is focused on agent-managed forked side threads.
  • I explained what changed and why.
  • I included before/after screenshots.
  • I included a short interaction recording.
  • Focused tests and package typechecks pass.

Note

Add agent-managed forked side threads with MCP toolkit and native Codex fork support

  • Introduces a thread.fork command and thread.forked event in the contracts layer, allowing threads to be forked from a source thread at a specific turn, with inherited messages carried into the new thread.
  • Adds a decider that validates fork requests, selects the cutoff turn, filters out streaming messages, and emits thread.forked with lineage and inherited messages; the projector and projection pipeline update the read model and DB accordingly.
  • Adds DB migration 033 that adds forked_from_thread_id and forked_from_turn_id columns plus an index to projection_threads.
  • Wires native fork support through CodexAdapter and CodexSessionRuntime via a thread/fork provider request, with ProviderService.startSession validating adapter capabilities and source thread binding before passing fork metadata.
  • Exposes a full MCP thread toolkit (thread_list, thread_create, thread_fork, thread_send, thread_archive) registered on the MCP server, with developer instructions updated to cover thread coordination tools.
  • Adds a "Fork to side chat" button in ChatHeader that creates a forked thread and opens it in the right panel as an embedded ChatView; newly detected forked child threads auto-open in the right panel within 2 seconds of mount.
  • Risk: incrementing RIGHT_PANEL_STORAGE_VERSION to 8 will reset persisted right-panel state for existing users.
📊 Macroscope summarized 41bbfc5. 29 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 08c0b8a9-e8bf-4653-9395-03389f6c441d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Jul 22, 2026
thread_list: (input: { readonly includeArchived?: boolean | undefined }) =>
Effect.gen(function* () {
const current = yield* currentThread();
const snapshot = yield* query.getShellSnapshot();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium threads/handlers.ts:103

thread_list ignores includeArchived: true — archived threads are always excluded from the result, even when the caller explicitly requests them. The handler only calls query.getShellSnapshot(), which does not return archived threads, so the subsequent input.includeArchived === true || thread.archivedAt === null filter can never surface archived entries because they were never in the snapshot to begin with. To honor includeArchived, the handler must also query (and merge) getArchivedShellSnapshot() when archived entries are requested.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/mcp/toolkits/threads/handlers.ts around line 103:

`thread_list` ignores `includeArchived: true` — archived threads are always excluded from the result, even when the caller explicitly requests them. The handler only calls `query.getShellSnapshot()`, which does not return archived threads, so the subsequent `input.includeArchived === true || thread.archivedAt === null` filter can never surface archived entries because they were never in the snapshot to begin with. To honor `includeArchived`, the handler must also query (and merge) `getArchivedShellSnapshot()` when archived entries are requested.

});
}

const cutoffIndex =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium orchestration/decider.ts:308

When the source thread has no completed assistant turn (e.g., forking during the first running turn), sourceTurnId is null and the fork inherits all non-streaming messages from the running turn — including the user message whose turnId is null — instead of producing empty history. With no completed turn to fork from, the cutoff should yield no inherited messages, not every non-streaming message in the thread. Consider treating sourceTurnId === null as an empty cutoff rather than slicing through the end of the message list.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration/decider.ts around line 308:

When the source thread has no completed assistant turn (e.g., forking during the first running turn), `sourceTurnId` is `null` and the fork inherits all non-streaming messages from the running turn — including the user message whose `turnId` is `null` — instead of producing empty history. With no completed turn to fork from, the cutoff should yield no inherited messages, not every non-streaming message in the thread. Consider treating `sourceTurnId === null` as an empty cutoff rather than slicing through the end of the message list.

)(function* (event, attachmentSideEffects) {
switch (event.type) {
case "thread.forked":
yield* Effect.forEach(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High Layers/ProjectionPipeline.ts:844

When a forked thread is later reverted, the entire inherited conversation is deleted from the projected fork instead of retaining its baseline history. The thread.forked projector preserves each inherited message's original turnId (line ~850) but never creates corresponding ProjectionTurn rows for those source turn IDs. On thread.reverted, retainProjectionMessagesAfterRevert keeps a message only when its turnId exists among the fork's retained projection turns — and inherited source turn IDs never match, so all inherited messages are dropped. Consider materializing ProjectionTurn rows for inherited turn IDs when handling thread.forked, or adjusting the revert retention logic so inherited baseline messages are preserved regardless of turn membership.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration/Layers/ProjectionPipeline.ts around line 844:

When a forked thread is later reverted, the entire inherited conversation is deleted from the projected fork instead of retaining its baseline history. The `thread.forked` projector preserves each inherited message's original `turnId` (line ~850) but never creates corresponding `ProjectionTurn` rows for those source turn IDs. On `thread.reverted`, `retainProjectionMessagesAfterRevert` keeps a message only when its `turnId` exists among the fork's retained projection turns — and inherited source turn IDs never match, so all inherited messages are dropped. Consider materializing `ProjectionTurn` rows for inherited turn IDs when handling `thread.forked`, or adjusting the revert retention logic so inherited baseline messages are preserved regardless of turn membership.

Comment on lines +1409 to +1418
...(input.forkFrom !== undefined && isCodexResumeCursorSchema(input.forkFrom.resumeCursor)
? {
forkFrom: {
providerThreadId: input.forkFrom.resumeCursor.threadId,
...(input.forkFrom.sourceTurnId !== undefined
? { sourceTurnId: input.forkFrom.sourceTurnId }
: {}),
},
}
: {}),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High Layers/CodexAdapter.ts:1409

startSession silently drops input.forkFrom when input.forkFrom.resumeCursor fails isCodexResumeCursorSchema, starting a fresh Codex thread instead of the requested fork. The adapter still advertises sessionFork: "native", so a caller passing a non-Codex (e.g. legacy or malformed) persisted cursor succeeds with a side thread that has no link to the source conversation, rather than failing the fork request. Consider returning a ProviderAdapterValidationError in this branch instead of silently omitting forkFrom.

-          ...(input.forkFrom !== undefined && isCodexResumeCursorSchema(input.forkFrom.resumeCursor)
+          ...(input.forkFrom !== undefined
+            ? isCodexResumeCursorSchema(input.forkFrom.resumeCursor)
+              ? {
+                  forkFrom: {
+                    providerThreadId: input.forkFrom.resumeCursor.threadId,
+                    ...(input.forkFrom.sourceTurnId !== undefined
+                      ? { sourceTurnId: input.forkFrom.sourceTurnId }
+                      : {}),
+                  },
+                }
+              : yield* new ProviderAdapterValidationError({
+                  provider: PROVIDER,
+                  operation: "startSession",
+                  issue: "forkFrom.resumeCursor is not a valid Codex resume cursor.",
+                })
+            : {}),
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/CodexAdapter.ts around lines 1409-1418:

`startSession` silently drops `input.forkFrom` when `input.forkFrom.resumeCursor` fails `isCodexResumeCursorSchema`, starting a fresh Codex thread instead of the requested fork. The adapter still advertises `sessionFork: "native"`, so a caller passing a non-Codex (e.g. legacy or malformed) persisted cursor succeeds with a side thread that has no link to the source conversation, rather than failing the fork request. Consider returning a `ProviderAdapterValidationError` in this branch instead of silently omitting `forkFrom`.

@nassimna

Copy link
Copy Markdown
Contributor Author

Superseded by #4248, which rebuilds this as the standalone selected-message fork feature and excludes all agent thread controls.

@nassimna nassimna closed this Jul 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant